Skip to content

Dev - #2104

Open
pikonha wants to merge 111 commits into
mainfrom
dev
Open

Dev#2104
pikonha wants to merge 111 commits into
mainfrom
dev

Conversation

@pikonha

@pikonha pikonha commented Aug 4, 2026

Copy link
Copy Markdown
Member

Note

Medium Risk
Changes SQL semantics for delegate activity and inactive VP across date windows; incorrect bounds could mislabel delegates inactive. New endpoints and feed filters are additive, but AAVE filter behavior is a breaking fix for clients that relied on combined VP ranges.

Overview
Holders & Delegates v3 adds API support for inactive delegated VP (GET /voting-powers/inactive-summary), former delegators (GET /accounts/:address/delegators/historical), and per-DAO treasury/vesting labels (GET /addresses/labels). Proposal activity gains optional toDate and stricter window rules: proposals count when voting opens (creation + voting delay), and votes only count if cast inside [fromDate, toDate]. Feed accepts relevance=ALL, optional address filtering, and optional delegatees on split delegations. AAVE fromValue/toValue on voting powers now filter delegated power only.

The dashboard renames the section to Stakeholders (/stakeholders with redirects from holders-and-delegates), defaults the Delegates tab, and extends AAVE delegate tables with amount filters and row borders. Create-proposal description limit rises to 100,000 characters with pre-save validation. DAO overview updates Security Council copy (configurable label, ENS July 2026 council per changeset) and links point at the new stakeholders routes.

Heavy unit test coverage backs former-delegator SQL, inactive-summary, proposals-activity date bounds, and feed address/split-delegation behavior.

Reviewed by Cursor Bugbot for commit d19b3d8. Configure here.

pikonha and others added 30 commits July 22, 2026 19:36
Dashboard:
- min/max value filters on Delegates (voting power) and Token Holders (balance)
- Delegates as default tab; sidebar renamed to "Stakeholders"
- larger rows with bottom borders, continuous activity ring, VP as percent of quorum
- inactive-delegate flag and 0/0 states (Inactive / No proposals / Never voted)
- inactive-VP alert banner on Token Holders; "Voted X/Y (Inactive)" on delegate column
- clickable addresses that re-point the drawer; per-address Activity tab
- Balance History In / Out / Vesting; dust badge and Hide dust switch on Top Interactions
- VP History low-importance filter and All time; time selector MAX plus custom calendar range
- delegate drawer tabs renamed (Voting Power, Delegation History); Former Delegators view
- proposal final-result filter on the votes tab

API:
- new endpoints: voting-powers/inactive-summary, accounts/:address/delegators/historical,
  addresses/labels; address filter on feed/events; proposalStatusIn on proposals-activity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address enrichment (ENS, contract flag, arkham labels) had no staleTime, and
the global QueryClient defaults to 0, so every EnsAvatar/TypeCell refetched on
each remount. Table re-renders (amplified by the inactive banner and per-row
activity fetch) remounted rows and fired/canceled these requests repeatedly,
flooding the address-enrichment API on every tab open. Give the useGetAddress
and useGetAddresses calls a 5m staleTime / 30m gcTime so identical addresses
dedupe and stay cached across remounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- votes: rename user-vote filter labels For/Against to Yes/No
- voting power: DS SegmentedControl for current/former view
- voting power: summary (Current VP / Total VP Lost) on the selector row
- former delegators: short date (Jan 3, 25), dedup VP impact when unchanged
- delegation history: net VP change on graph, low-importance toggle on CSV row
- activity: DS SegmentedControl for date and relevance
- token holders: banner uses DS InlineAlert, table fills height below it
- top interactions: hide-dust on CSV row, total as USD, Net Tokens In/Out (90D)
- balance history: net balance change value on graph
- period label: All time renamed to Max available data
- table: footerActions slot; inline alert accepts ReactNode content

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EnsAvatar: optional subtitle slot rendered under the name; the avatar
  stays vertically centered against the whole name + subtitle block
- token holders: delegate column renders "Voted X/Y" via the subtitle slot
  so the avatar aligns with both lines
- table: row dividers now live on the cells, since border-separate tables
  do not paint borders set on the <tr>

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against the Figma frame measured pixel by pixel, and against the
locally rendered table:

- delegate cell: avatar is vertically centered against the whole
  name + "Voted x/y" block (measured offset 0), and the subtitle starts at
  the same x as the name, as in the design
- row borders: border-separate tables never paint borders declared on the
  <tr>, and the first cell additionally cleared them on desktop, so the
  divider was missing entirely and never reached the Address column. The
  line is now drawn by a cell pseudo element spanning the full cell width,
  ignoring the horizontal padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- address column: long arkham labels are truncated instead of overflowing. The
  chain was broken by the tooltip trigger button, an inline-block whose
  shrink-to-fit width was set by the label, so `truncate` never had a width to
  work with. Clamping the trigger fixes every table using EnsAvatar.
- avg vote timing: shows a skeleton while the per-row proposals activity loads,
  instead of the "-" it uses for delegates with no votes.
- calendar popover: uses rounded-base, so the radius follows the DS token
  (0 for Anticapture, non-zero for whitelabels) instead of a hardcoded md.
- token holders change column: right aligned, matching the delegates tab.
- balance history and voting power graphs: the heading no longer changes between
  loading and loaded states.
- drawer activity: the feed is scoped to the inspected wallet again. The address
  filter was fine; "All" omitted `relevance`, which the API reads as MEDIUM, so
  its value thresholds hid almost everything. The API now takes relevance=ALL to
  drop the threshold, and the drawer always sends the value explicitly.
- drawer activity: infinite scroll works. The observer used the viewport as root
  while the list scrolls in its own container, leaving the sentinel on the
  clipped edge and the feed stuck on page one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ment

- holders and delegates page title reads "Stakeholders", matching its nav entry
- delegate votes: the rate metric card is labelled "For Rate"
- former delegators: VP Impact header aligns left
- voting power summary: the loading skeleton aligns left with the value it
  replaces

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ENS record can be a 42 character address plus ".eth", which overflowed the
address column and ran into the next one. DrawerAddressButton wraps the avatar
in a button, and a button is inline-block, so it was sized by its content and
the name below it never had a width to truncate against. Clamping it fixes every
drawer table that renders an address.

VP Impact is centered in both the header and the cells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rated enum

The previous guard compared against "ALL" directly, which only typechecks when
the client happens to be generated from a spec that already exposes that value.
CI regenerates the client against whichever Gateful it can reach, so the
comparison broke there. Checking membership in the tiers this page offers works
either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dule

Retry loop: useDelegatesActivity only recorded addresses whose fetch succeeded,
so a rejected (or empty) response left the address selectable again as soon as
it left the loading set. Since the effect depends on both sets, that refetched
the same addresses forever while the endpoint kept failing. Fetches now settle
into their own set, and one address failing no longer discards the others.

AAVE: its API registers no proposal endpoints, so the shared TokenHolders was
firing a 404 per delegate for proposals-activity and a 400 for the inactive
summary, which falls through to the /voting-powers/{address} param route. Both
are now gated on the DAO exposing proposal activity.

activityFromDate was recomputed from Date.now() on every render, so it changed
whenever a render crossed a second boundary and re-keyed both the per-delegate
activity cache and the banner query. Memoized on its inputs.

Total VP Lost summed only the pages already loaded while printing the API's
count of every former delegator beside it. It now claims a total only once
there is nothing left to load.

Canceled proposals are no longer votable in the inactive summary window, so it
agrees with proposals-activity instead of reporting a delegate as inactive for
skipping a vote that never happened.

Also: failed requests in the drawer activity feed and the former delegators
table no longer render as "nothing found"; the USD column shows a dash instead
of a confident "$0" while the token price is in flight; the Total Interactions
tooltip describes the value it actually shows; and an address inside
DrawerAddressButton no longer nests the tooltip trigger button inside the row
button, which cost a second tab stop and hijacked the accessible name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The filter matched the stored proposals_onchain.status, but the service
overwrites each proposal's status at read time with a derived value, and the
indexer only ever persists ACTIVE, CANCELED, EXECUTED, PENDING, QUEUED and
VETOED. DEFEATED, SUCCEEDED, NO_QUORUM, EXPIRED and PENDING_EXECUTION could
therefore never match: on ENS "Failed" returned zero rows and zeroed all four
metric cards, "Passed" silently dropped SUCCEEDED, and "Canceled" was
unsatisfiable because the query already excludes canceled proposals.

Making it correct means persisting the derived status from the indexer, or
expressing the derivation in SQL over end block, vote tallies, quorum and
timelock. That is its own task, so the filter and the proposalStatusIn param
come out for now. The unrelated user vote filter on the same table stays.

Also drops the "(90D)" from the Net Tokens In/Out label, which claimed a window
the request never asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs link on its table

The activity requests only ever sent fromDate, so with a custom range that ended
in the past "Voted X/Y" counted every proposal from the range start up to today.
The banner above the same table did send toDate, so the two disagreed about the
window they described. Both hooks now send both bounds.

Making Delegates the default tab broke the DAO overview entry point: the
"Biggest holdings change" card links to holders-and-delegates with no tab param,
so it landed on Delegates instead of the holders table it describes. It now asks
for tokenHolders explicitly, like its delegate-side sibling already did.

Also hardens the activity fetch in useDelegates the same way useDelegatesActivity
was hardened. The retry loop there predates this PR, but promoting Delegates to
the default tab makes it the first thing most visitors hit, so a failing
proposals-activity endpoint would now hammer the API from the landing tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex:
- Namespace the page-level custom range as rangeFrom/rangeTo. Plain from/to
  belong to the drawer's Balance History address filters, so a custom range
  was sending date strings as addresses and getting cleared on tab changes.
- Collapse delegation rows per source event before sequencing former
  delegators. DAOs with partial delegation (SCR) write one row per delegatee
  out of a single DelegateChanged, sharing tx hash, log index and timestamp,
  so sibling delegates were read as moving away from each other. Only name a
  redelegation destination when the move-away event points a single
  delegation away from the queried address.
- Move FeedEventItem into shared/. It also imported EntityType back from
  holders-and-delegates, so that type moves to shared/types/entities.ts,
  which clears two pre-existing violations in dao-overview too.
- Drop the proposal final-result filter clause from the changeset; it was
  removed from this PR in 30daf70.

isadorable:
- Restore the "Holders & Delegates" page heading. Only the sidebar entry
  becomes "Stakeholders", per the DEV-562 decision. The subtitle prop that
  was meant to preserve it is dead: TheSectionLayout never rendered it, so
  drop it from the call sites and from the props type.
- Replace the activity ring's hardcoded hex with stroke-border-contrast and
  stroke-success. The track was dark-mode --base-border, which rendered as
  dark grey on a light background.
- Give the AAVE delegates table the DEV-476 min/max filter, and document why
  it is a reduced version of the shared Delegates table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
API:
- proposals-activity accepts an optional `toDate`, enforced on the proposal
  timestamp in both the page and the analytics queries. The dashboard already
  sent it for custom ranges, where it was silently discarded, so bounded
  periods counted every proposal through today.
- the feed's delegation enrichment keeps the row that mentions the filtered
  address. Partial delegations (SCR) write one row per delegatee out of a
  single DelegateChanged, all sharing tx hash and log index, so collapsing
  them by key could describe a delegate unrelated to the filtered address.

Dashboard:
- balance-change and voting-power-change totals read their period boundaries
  from their own limit-1 lookups instead of the plotted rows, which are capped
  at 1,000 and hide small events. Active accounts were reporting the change
  over a truncated suffix of the period.
- "Hide dust" moves into the interactions query. Filtering client-side could
  empty a page, and an empty table drops the infinite-scroll sentinel, leaving
  qualifying rows on later pages unreachable.
- per-address activity fetches carry a generation for the DAO and range, so a
  superseded response can no longer merge stale proposal counts into the rows.
- the drawer's Activity tab only renders for DAOs whose API serves the feed;
  AAVE showed a permanent error state. An unknown tab in the URL now falls
  back to the first one instead of an empty body.
- clicking an address in the drawer feed carries its entity type, so a
  delegate opens the delegate profile rather than the token-holder tabs.
- the custom range calendar can apply a single day, via an explicit Apply.
  react-day-picker answers the first click with `from` equal to `to`, so the
  old inequality check made one specific day unselectable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…0860)

Every in-drawer address column now states which kind of profile it points
at, so DrawerAddressButton sets the drawerEntity override the same way the
drawer's activity feed does. Clicking a delegator in a delegate drawer no
longer opens that holder with delegate tabs, and clicking a delegate from a
token holder's delegation history no longer keeps token holder tabs.
The holders and delegates tab param is parsed as an enum instead of a plain
string, so a stale or hand-edited ?tab=foo coerces back to the default tab
rather than missing every key in tabComponentMap and rendering an empty
section body.
…eview #3675140866)

Partial delegation writes one delegations row per delegatee out of a single
DelegateChanged, so the primary row alone describes the event badly: an
unfiltered feed renders one arbitrary delegatee, and a feed filtered by the
delegator matches every sibling row, so picking one drops the others.

FeedDelegationMetadata gains an optional 'delegatees' array of
{ delegate, amount }, ordered by delegate address ascending and present only
when the event has more than one row. 'delegate', 'amount' and
'previousDelegate' keep their meaning and still come from the primary row that
indexDelegationsByKey selects, so the deployed client and the dashboard feed
renderer are unaffected.
…ages (review #3676245485)

"Hide dust" is on by default and enforced by the query, so an address whose
every interaction is under $1 came back empty and the early return took the
whole table away, footer switch included, with no way to turn the filter back
off. The table now stays mounted and shows an empty state that names the filter
responsible. TopInteractions owns the genuinely-no-interactions case instead:
its query carries no filters, so it hides the table and shows its blank slate
alone rather than stacking two empty states.
…ew #3676245492)

The override was a bare entity type, so any path that cleared drawerAddress
without clearing it too (the section's tab cleanup, each parent's onClose) left
it behind for the next address opened from a table, which then rendered the
wrong profile's tabs.

It is now recorded as '<entityType>:<address>' and honored only while the
recorded address matches the drawer's, compared case-insensitively since the
two come from different sources. Re-pointing the drawer drops the override by
itself, so no cleanup path has to remember it, and a future one cannot
reintroduce the bug. Encoding lives in a single useDrawerEntityOverride hook
shared by both writers; DrawerActivityFeed writes through it directly, which
retires the onEntityTypeChange prop that could not carry the address.
Drop comments that restated the code they sat on: JSX section labels
(Filters, Timeline, Delegators), component headers that paraphrased the
component name, and a guard comment narrating its own condition.

Tighten the remaining ones to the reason the code is the way it is, and
drop the trailing ticket refs, which point at the PR rather than at the
code.

Comments only, no behavior change.
…iew)

The AAVE delegates table renders `combined - balance` as "Delegation
received", but the repository applied `fromValue`/`toValue` to the combined
total (delegated power plus the account's own balance). A large self balance
alone could satisfy a minimum, and it could push a genuinely delegated
account past a maximum. Filter the delegated power expression instead, which
also matches what `orderBy=votingPower` already sorts by on the same
endpoint, and what every other DAO does.

Also guard the Top Interactions `minAmount`/`maxAmount` parse: both come
from the URL, so a stale or hand-edited `?minAmount=1.5` threw inside
`BigInt()` while rendering and took the drawer down. Invalid values are now
ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex review (PR 2084, round 5) plus findings from a pass over the rest of
the diff looking for the same defect classes.

Bound votes by `toDate` in both activity aggregates. The window holds every
proposal whose voting period overlaps the range, so a proposal opened near
the end stays votable after it; a vote cast later was counting as activity
inside a period that closed before the vote existed. The bound goes in the
LEFT JOIN's ON clause, never in WHERE, so the proposal is still listed with
no vote attached, which also keeps the `no_vote` filter and the voteTiming
ordering consistent. `getUserVotes` takes the same bound so the analytics
(votedProposals, winRate, yesRate, avgTimeBeforeEnd) agree with the page.
The banner copy, "no votes cast in the selected period", is the semantics
being enforced here.

The lower bound is deliberately left off: the window includes proposals that
opened before the range and were still votable inside it, and a vote on one
of those is real participation, so bounding below would trade this overcount
for an undercount.

Self-review findings:

- Voting Power History let a user minimum below 1 token replace the low
  importance floor instead of combining with it, so sub-token rows came back
  while the switch still read as on. It now takes the larger of the two, and
  ignores an unparseable URL value rather than letting it defeat the floor.
- Delegates did not validate `drawerAddress` while its sibling Token Holders
  tab does, so a hand-edited value opened a drawer every address query below
  rejects. Both use `parseAsAddress` now.
- The AAVE page parsed `tab` as a plain string, so `?tab=foo` rendered Token
  Holders with neither button highlighted. Enum parsed, which also retires
  the `as TabId` cast. This one predates the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brunod-e and others added 18 commits August 4, 2026 21:11
Unifying the stored-args-to-calldata conversion put the encoder on
storageToArg, which reads an unparseable array or tuple as an empty
container. A draft that never passed ProposalFormSchema could publish
valid calldata for an empty array while the action row described a call
with arguments, with nothing to see either side of the publish.

storageToArgStrict is now the conversion, refusing a blank, unparseable
JSON, or JSON that is not an array; storageToArg is that plus the empty
fallback, so the modal's live preview still renders while an array is
half-typed and the two cannot drift.

review: fail closed on malformed composite args (#3715934430)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oint

The proposal list needs the new-proposal menu, the JSON import modal and
the handoff that carries an imported proposal to the form. Those arrived
as three more imports into create-proposal's internals, on top of the
paths GovernanceSection already reached into.

They go through the feature's existing barrel instead, so the governance
side names one entry point and the internal layout stays movable. The
two older internal paths are left alone: untangling them is a separate
question from what this PR adds.

review: move proposal import UI behind the feature barrel (#3715934434)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s in

The proposals list stashed a pending import under the raw `daoId` route param
while the creation form read it back lowercased, so opening `/ENS/proposals`
stored under `ENS` and the form looked under `ens`. The import succeeded, the
navigation happened, and the author landed on an empty form with nothing said.

Normalize in `keyFor`, the one place both sides go through, rather than at a
single call site, so the two stay in agreement whatever a caller passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ses it

stashImportedProposal returns false when sessionStorage is blocked or full,
so the caller can keep the author in the dialog with their document. The
dialog defeated that: it called onImport and then closed unconditionally,
and closing clears the textarea, so a storage failure showed a toast and
took the hand-written JSON with it.

onImport now reports whether it took the values, and the dialog closes only
on a true. GovernanceSection returns the result of the stash, keeping its
error toast on failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hold

storageToArgStrict is the encode path, but it stringified every leaf, so a
shared or API draft that never passed ProposalFormSchema could carry a
composite arg like [null] or [{}] and have it become "null" or
"[object Object]". customActionIssues reads that as a filled-in string leaf,
and encodeActions published a value nobody wrote instead of failing closed.

The strict conversion now walks the parsed value against the parameter:
only JSON scalars are leaves, a list is required wherever the ABI says one
goes, a value wherever it says a value goes, and a tuple may not carry more
entries than it has components. The lenient conversion still degrades to the
empty container, so the modal's live preview keeps rendering mid-edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Validation parsed composite args with the forgiving converter while
encodeActions used the strict one, so the two could disagree. A leaf the ABI
cannot hold, `[{}]` or `[null]` for a `string[]`, or an object where a
`tuple[]` wants a tuple, parses as a JSON array and clears the shape checks;
the forgiving converter then degrades it to the empty container, which counts
as a complete dynamic array. Publish turned on and encodeActions threw on the
very same arg. Reachable from any draft that did not come through the import
converter, saved or shared.

customActionIssues now converts with storageToArgStrict, so whatever it
accepts is what argsToTreesStrict can encode. The composite pass ahead of it
stays, only to keep naming the two failures worth naming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`coerceStrict` filled a missing tuple field with "", so a stored action
holding `[]` for `tuple(string memo)` converted to `[""]` and published
calldata carrying an empty string the draft never described. The row said
one thing, the chain got another.

A count that does not match is now refused in both directions, the same
way an extra entry already was. The lenient `storageToArg` keeps its
fallback, so a short tuple hydrates as the empty struct instead of a
padded one; the modal writes full arity tuples back regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things, both in the conversion layer between a stored arg and calldata.

The safe conversion is now the default one. `storageToArg` and `argsToTrees`
are the strict pair that refuses anything the ABI cannot hold, and the
forgiving pair is `storageToArgForDisplay` and `argsToTreesForDisplay`, named
for the only thing that answer is good for. Every bug in this file came from a
caller reaching for the forgiving variant to decide whether an action was
complete, so the plain name is the one that fails closed and the other has to
be asked for. The custom action modal now draws its inputs from the display
conversion and gates its Add button and calldata preview on the strict one,
which it was not doing.

And the utils are smaller. The JSON scanner loses its class and tracks only
numbers, which are the only values the parser rejects for being unquoted, so it
still reports the line and the digits as written for a third of the size.
importIssueCopy was three string formatters with one consumer, so it lives in
that consumer now. importedArgs loses the per-failure object literals to three
helpers, without changing a message. Comments across all of them are cut back
to invariants, from up to 75 percent of a file down to the repo's own range.

  scanJsonSource   316 to 153, plus 200 to 110 in its test
  importedArgs     299 to 215
  importHandoff     76 to  49
  address           29 to  11
  importIssueCopy  149 to   0, deleted with its test

No behaviour change beyond the modal gate: 463 tests pass, and the three that
asserted forgiveness now ask for it by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`argsToTrees` mapped over the ABI's inputs alone, so a missing arg read as
"" and an extra one was dropped. An action whose args and function drifted
apart in a draft that never passed `ProposalFormSchema` therefore published
calldata its row never described: `setMessage(string)` with `args: []`
encoded an empty string instead of failing. Same fail-closed reasoning as
the tuple arity check in `coerceStrict`, one level up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment described `storageToArg` as forgiving and referred to
`argsToTreesStrict`; both names moved when the strict variants became the
plain ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An import stashes before it navigates, because a sign-in can leave the page
and the values have to survive it. Signed out, `goToNewProposal` only opened
the sign-in modal, and dismissing it cleared the redirect while leaving the
stash: the next "Create new" for that DAO in the tab drained it and filled a
form that was asked to be blank, announcing an import the author had walked
away from and arming the dirty flag NavigationGuard reads.

`openLogin` now takes an `onDismiss`, so whoever staged for the post-sign-in
route owns undoing it. Deferring the stash instead was not available: magic
link and OAuth unload the page, which is why the handoff is sessionStorage
rather than state.

The undo is not gated on there being no session. `LoginProvider` reads
`useSession` while its callers gate on `useAuthSession`, and the two disagree
for a stale wallet session — which an open modal actively keeps alive, since
`authFlowActive` stands the sign-out effect down. Gating would have skipped
the undo in exactly the case the caller was signed out from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The magic-link sign-in mails an absolute URL back to the creation form, and
following it from a mail client opens a new browsing context. sessionStorage
is per-context, so `takeImportedProposal` found nothing there: the author
signed in successfully and landed on a blank form with their document gone
and nothing said. Surviving a sign-in that leaves the page is the whole job
of this module, and only cross-context storage does it.

The comment above `keyFor` had justified sessionStorage over the URL, which
is a question about size, and that was mistaken for having settled the
question about scope.

localStorage costs what sessionStorage was buying: any tab can drain the
stash, and it outlives the tab that wrote it. So the record now carries its
write time and is refused after an hour — long enough for a mail round trip,
short enough not to fill a form in a later session that nobody asked to
fill — and is cleared whether it was accepted, expired, or malformed. Two
tabs importing for the same DAO share the key and the last write wins, which
is accepted and written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	apps/dashboard/features/create-proposal/schema.ts
…e style

AGENTS.md asks for arrow functions; `createMemoryStorage` in the import handoff
test was the one `function` declaration this PR adds, so it is converted. The
other declarations in these files come from dev untouched and are left alone.

Comment blocks were the bigger drift: 27 of them ran past 8 lines, against a
ceiling of 3-8 in dev's own version of the same files. All 26 that this PR
introduced are cut to the essential reason; the 16-line block in LoginProvider
is dev's and stays as it is.

Comments and one declaration keyword only — no behaviour change. 487 tests,
typecheck and lint all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JSON import restated rules the form already owned, and the parameter
walk they both need was written out at every call site. Three passes
answered "can this argument be encoded as what it claims to be" —
isArgComplete for the editor, tupleArityError for a better arity message,
and the import's own translation — and they disagreed on arity wording and
on whether an empty dynamic array counted as filled in.

- argIssues is now that single answer, and isArgComplete is it as a
  boolean. tupleArityError and the composite pre-pass are gone.
- customActionIssues moves from ProposalFormSchema's superRefine onto
  ProposalActionSchema, so the import dialog reaches it before the form
  exists. A paste naming a function that isn't in its ABI is refused in
  the dialog instead of turning up later as a Publish button that will
  not enable.
- The import's parallel action schemas are derived from the form's
  members (PendingProposalActionSchema) rather than kept in step by hand.
- shapeOf replaces the array/tuple/leaf preamble that ten walkers each
  spelled out, and arityError is the one place a container's declared
  size is compared with what it holds.
- Four identical {path, message} types collapse to one Issue.

The Solidity type grammar moves to abitype's zod schemas, which viem is
already built on: uint257, uint255, bytes33, bytes0, fixed128x18 and a
tuple with no components were all being caught by hand-written regexes.
Readability stays a separate, looser check, because judging it with the
grammar drops the offending function from the ABI list and loses the
message that names the bad type. `function` stays manual — legal ABI that
viem's encoder refuses.

Numbers no longer have to arrive quoted. The document is read through
jsonc-parser, so 1000000000000000001 and 1.000000000000000001 survive as
the text they were written as instead of as rounded doubles, which is what
the quoting rule existed to work around. That also retires the
hand-written source scanner: line numbers now come from the syntax tree,
for every path rather than only for figures.

One consequence worth naming: a number where text belongs is now read as
that text, so {"title": 42} imports a title of "42" rather than being
refused.
Both already resolved in the tree — abitype as viem's own dependency,
jsonc-parser transitively — so this only declares them.
987 comment lines across the feature, 19% of everything the branch added.
Most of it narrated the code beside it or recorded what the code used to
be, which is review context with a shelf life, not something the next
reader needs.

What stays is the set of things that cost a bug or would cause one: a
pasted `decimals` reaching `parseUnits`, raw calldata winning over
`functionName` in the encoder, the modal's ticket against a stale decimals
lookup, `storageToArg` vs the forgiving variant, the checksum-agnostic
resolver, `function` being legal ABI that viem refuses, and why the
document is not read with `JSON.parse`. One or two lines each, on the line
they guard.

Comments now 164 of 4,303 added lines (3%): 144 in production, 20 in
tests, where a name already says what a case is for. No code changed —
521 tests pass, lint clean, tsc identical to baseline.
feat(dashboard): import a proposal from JSON when creating one

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6450e32cb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +29 to +32
ImportJsonModal,
NewProposalMenu,
clearImportedProposal,
stashImportedProposal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move proposal-import orchestration out of governance

Move the newly imported modal, menu, and local-storage handoff out of the governance feature. These imports couple governance directly to create-proposal internals, so either feature can no longer evolve independently; compose the flow at an app/widget boundary or move genuinely reusable orchestration into shared.

AGENTS.md reference: AGENTS.md:L100-L101

Useful? React with 👍 / 👎.

size="md"
aria-haspopup="menu"
aria-expanded={open}
{...triggerProps}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Attach proposal-create telemetry to the selected action

When GovernanceSection supplies proposal_create_click through triggerProps, spreading those props onto the popover trigger records the event as soon as a user merely opens the menu. ConditionalPostHog captures the closest tagged element on that click, so cancellations and users who subsequently choose Import JSON inflate the proposal-creation funnel; put the existing event tags on the “Create new” menu item and give imports a distinct event if needed.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 139c236f70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +386 to +388
<AmountFilter
filterId="delegates-voting-power-filter"
sortOptions={AMOUNT_SORT_OPTIONS}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize amount-filter state with URL filters

The table query is driven by minValue/maxValue, but this AmountFilter initializes its fields from a separate persistent Zustand instance and receives none of those URL values. After applying a filter and switching tabs, cleanupFilters clears only the URL, so returning and pressing Apply unexpectedly restores the stale range; conversely, reloading or opening a shared filtered URL shows blank inputs despite an active filter. Initialize/reset the filter store from the query state so the displayed and applied values match the rows.

Useful? React with 👍 / 👎.

Comment on lines +138 to +140
// include the whole end day
toDate:
Math.floor(customRange.to.getTime() / 1000) + DAY_IN_SECONDS - 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the custom end boundary from local midnight

For users in a DST-observing timezone who select an end date on a clock-change day, adding a fixed 86,400 seconds does not include exactly that local calendar day. A spring-forward end date includes roughly the first hour of the following day, while a fall-back date omits roughly the final hour, so transfers and delegate activity around that boundary appear in the wrong custom range. Compute the next local calendar midnight and subtract one second instead.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants